Popular Searches
Popular Course Categories
Popular Courses

Applying Repository Patterns and Modular Architecture

Applying Repository Patterns and Modular Architecture

App Architecture, Libraries, and Build Management

Applying Repository Patterns and Modular Architecture in Android

Course: Android App Development

Topic: Applying Repository Patterns and Modular Architecture

Course Link: Android App Development Course

Demo Registration: Register for a Course Demo

Website: JustAcademy


1. Introduction

As Android applications grow, managing user interfaces, business logic, network requests, local databases, authentication, and background tasks in a single place becomes difficult. Poorly organized code can lead to duplicated logic, complicated testing, tightly coupled classes, and changes that affect unrelated features.

Repository patterns and modular architecture help solve these problems. The repository pattern provides a clear boundary between application logic and data sources, while modular architecture divides a project into smaller, focused, reusable components.

Android's recommended architecture uses distinct UI and data layers, with an optional domain layer for applications that need reusable or complex business logic. Repositories are strongly recommended for exposing application data, and modules can help enforce boundaries as a codebase grows.

2. Learning Objectives

  • Understand the repository pattern and its role in Android architecture.
  • Differentiate repositories, data sources, ViewModels, and use cases.
  • Implement a repository using Kotlin and coroutines.
  • Understand the difference between packages and Gradle modules.
  • Design feature-based and layer-based modular structures.
  • Manage module dependencies and avoid circular dependencies.
  • Apply dependency injection and dependency inversion.
  • Test repositories and maintain a scalable Android application.

3. What Is the Repository Pattern?

The repository pattern is an architectural pattern that provides a consistent interface for accessing and modifying application data. Instead of allowing a ViewModel or UI component to communicate directly with a database, API, or other provider, the component uses a repository.

The repository decides how data is obtained, stored, refreshed, or combined. This hides implementation details from the layers that consume the data.

Example

Consider a shopping application that displays a list of products. Product data might come from a remote API, a local Room database, or a cached copy. Without a repository, the ViewModel might need to understand all these sources. With a repository, the ViewModel requests products through one clear interface.

Repository responsibilities

  • Provide a consistent API for reading and updating data.
  • Coordinate remote and local data sources when necessary.
  • Transform data models into models suitable for the application.
  • Apply data retrieval and synchronization policies.
  • Expose observable data using Kotlin Flow when appropriate.
  • Make the data layer easier to test independently.

4. Understanding the Main Architecture Layers

Layer or Component Main Responsibility Example
UI layer Displays information and handles user interactions. Jetpack Compose screen or Fragment
ViewModel Prepares and exposes UI state and coordinates screen actions. ProductViewModel
Domain layer Contains reusable business rules and use cases when needed. GetProductsUseCase
Repository Provides a stable interface for accessing application data. ProductRepository
Remote data source Communicates with remote services. ProductApiService
Local data source Reads and writes locally stored data. Room DAO

The domain layer is optional. A small application may communicate from its ViewModel to a repository directly. Add use cases when business logic is complex or shared by multiple ViewModels.

5. How the Repository Pattern Works

A typical request flows through the following components:

  1. The user opens a screen or performs an action.
  2. The UI communicates the action to its ViewModel.
  3. The ViewModel requests the required operation from a repository or use case.
  4. The repository coordinates the relevant data sources.
  5. The data source retrieves information from an API, database, or another provider.
  6. The repository returns the result or emits updated data.
  7. The ViewModel exposes updated UI state, and the UI renders it.
User
  |
  v
UI Screen
  |
  v
ViewModel
  |
  v
Use Case (optional)
  |
  v
Repository Interface
  |
  v
Repository Implementation
  |
  +------> Remote Data Source (API)
  |
  +------> Local Data Source (Room)

This structure supports separation of concerns and unidirectional data flow. UI components do not need to know how a database or network service works.

6. Creating a Repository Interface in Kotlin

A repository interface defines the operations that other components are allowed to perform. It describes what the application needs without exposing how the data is obtained.

data class Product(
    val id: Int,
    val name: String,
    val price: Double
)

interface ProductRepository {
    fun observeProducts(): kotlinx.coroutines.flow.Flow<List<Product>>

    suspend fun refreshProducts()

    suspend fun getProductById(id: Int): Product?
}

In this example:

  • observeProducts() exposes a stream of product lists.
  • refreshProducts() requests an update from the appropriate source.
  • getProductById() retrieves a single product.

The interface does not mention Retrofit, Room, Firebase, or any particular storage technology. An implementation can be changed without forcing consumers to depend on that implementation.

7. Implementing a Repository

A concrete repository implements the interface and coordinates data sources. The following simplified example illustrates a remote data source. A production application may also store results locally.

interface ProductRemoteDataSource {
    suspend fun fetchProducts(): List<Product>
    suspend fun fetchProductById(id: Int): Product?
}

class DefaultProductRepository(
    private val remoteDataSource: ProductRemoteDataSource
) : ProductRepository {

    private val products =
        kotlinx.coroutines.flow.MutableStateFlow<List<Product>>(emptyList())

    override fun observeProducts():
        kotlinx.coroutines.flow.Flow<List<Product>> {
        return products
    }

    override suspend fun refreshProducts() {
        products.value = remoteDataSource.fetchProducts()
    }

    override suspend fun getProductById(id: Int): Product? {
        return remoteDataSource.fetchProductById(id)
    }
}

This is an illustrative in-memory implementation, not a persistent cache. Its list is lost when the repository instance is recreated. In a real application, a Room database or another persistent store can provide a durable source of data.

Why use an interface and implementation separately?

  • Consumers depend on the repository contract rather than a specific data provider.
  • Tests can supply fake implementations without making real network calls.
  • The implementation can change from remote-only access to local caching without changing the ViewModel's public dependency.
  • Different implementations can be supplied for production, testing, or preview scenarios.

8. Connecting a Repository to a ViewModel

The ViewModel uses the repository to obtain application data and exposes a UI state that the screen can observe. It should not directly create API clients or database instances.

data class ProductUiState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ProductViewModel(
    private val repository: ProductRepository
) : androidx.lifecycle.ViewModel() {

    private val _uiState =
        kotlinx.coroutines.flow.MutableStateFlow(ProductUiState())

    val uiState: kotlinx.coroutines.flow.StateFlow<ProductUiState> =
        _uiState

    init {
        loadProducts()
    }

    private fun loadProducts() {
        androidx.lifecycle.viewModelScope.launch {
            _uiState.value = _uiState.value.copy(
                isLoading = true,
                errorMessage = null
            )

            try {
                repository.refreshProducts()
                repository.observeProducts().collect { products ->
                    _uiState.value = ProductUiState(
                        products = products,
                        isLoading = false
                    )
                }
            } catch (exception: Exception) {
                _uiState.value = ProductUiState(
                    isLoading = false,
                    errorMessage = "Unable to load products."
                )
            }
        }
    }
}

Implementation note: This simplified example collects a stream after refreshing. In a production implementation, consider collecting the repository stream independently of refresh, and use an appropriate flow-sharing strategy so loading, refresh errors, and existing cached data are represented correctly. Import the coroutine and lifecycle APIs used by the project.

9. Repository Pattern with Local and Remote Data Sources

Many applications need to work with intermittent connectivity. A common design stores data locally while using a remote API to synchronize changes.

Example: Offline-first product catalogue

  1. The screen observes products from the local database.
  2. The repository requests fresh products from the server.
  3. After a successful response, the repository saves the new records to Room.
  4. Room emits an updated product list.
  5. The UI refreshes automatically through the observed stream.
interface ProductLocalDataSource {
    fun observeProducts(): kotlinx.coroutines.flow.Flow<List<Product>>
    suspend fun saveProducts(products: List<Product>)
}

class OfflineFirstProductRepository(
    private val local: ProductLocalDataSource,
    private val remote: ProductRemoteDataSource
) : ProductRepository {

    override fun observeProducts() = local.observeProducts()

    override suspend fun refreshProducts() {
        val latestProducts = remote.fetchProducts()
        local.saveProducts(latestProducts)
    }

    override suspend fun getProductById(id: Int): Product? {
        return local.observeProducts().first()
            .firstOrNull { it.id == id }
            ?: remote.fetchProductById(id)
    }
}

This example demonstrates the pattern only. It assumes that the local data source exposes a Flow of products and that the required Flow extension imports are present. For a full application, use a dedicated DAO query for a single product rather than loading the entire list. Also define an explicit strategy for refresh failures, stale data, synchronization conflicts, and deletion.

Common data strategies

Strategy How It Works Typical Use
Network-first Attempt to fetch current information from the server. Data that must be fresh
Cache-first Read cached data before making a network request. Fast initial display
Offline-first Use the local source as the primary observable source and synchronize when possible. Notes, saved content, product catalogues
Memory cache Keep frequently used information in memory for quick access. Short-lived data and performance optimization

10. What Is Modular Architecture?

Modular architecture organizes a codebase into modules that have clear responsibilities and explicit dependency boundaries. In Android, a module is commonly defined through Gradle and can be an application module, Android library module, or Kotlin/Java library module.

Packages organize files inside a module. Modules create stronger build-level boundaries and can declare which other modules they depend on.

Benefits of modularization

  • Maintainability: Developers can locate related functionality more easily.
  • Reusability: Common code can be shared by multiple features.
  • Testability: Modules with clear interfaces can be tested in isolation.
  • Team collaboration: Teams can work on separate features with fewer conflicts.
  • Build organization: Gradle can reason about module dependencies and affected tasks.
  • Encapsulation: Internal implementation details can be hidden behind public APIs.

Modularization adds build configuration and dependency-management overhead. It should be introduced where it provides meaningful boundaries, rather than creating a separate module for every class.

11. Packages Versus Gradle Modules

Feature Package Gradle Module
Purpose Organizes classes and files by namespace. Defines a build unit and dependency boundary.
Build configuration Usually shares its containing module's build configuration. Can have its own build configuration.
Dependency boundary Does not independently declare Gradle dependencies. Can declare dependencies on other modules and libraries.
Typical example com.example.data.repository :feature:products

12. Common Android Module Types

Module Type Purpose Example
Application module Contains the app entry point and builds the installable application. :app
Android library module Contains Android-specific code and resources reusable by other modules. :core:database
Kotlin/Java library module Contains platform-independent Kotlin or Java code. :core:common
Feature module Encapsulates a feature such as authentication or product browsing. :feature:products
Data module Contains data models, repositories, and data-source implementations for a domain. :data:products

13. Designing a Modular Android Project

A small project may begin with a single application module and packages for UI, data, and domain code. As the application grows, related components can be extracted into Gradle modules.

Example project structure

MyAndroidApp/
├── app/
├── core/
│   ├── common/
│   ├── network/
│   ├── database/
│   ├── model/
│   └── designsystem/
├── data/
│   └── products/
├── domain/
│   └── products/
└── feature/
    ├── home/
    ├── products/
    ├── productdetails/
    └── profile/

This is one possible arrangement, not a mandatory structure. A small project may combine domain and data code in fewer modules. A larger project may organize modules by business domain, feature, or a combination of both.

Responsibilities of the example modules

  • :app configures the application, navigation, and dependency injection composition.
  • :core:common contains genuinely shared, platform-independent utilities.
  • :core:network provides shared networking infrastructure.
  • :core:database configures Room and shared database utilities.
  • :core:model holds carefully selected shared model types.
  • :data:products implements product repositories and coordinates product data sources.
  • :domain:products contains product-related business rules and use cases if needed.
  • :feature:products contains the product screen, its ViewModel, and UI state.

14. Combining Repository Patterns with Modular Architecture

Repository patterns and modular architecture complement each other. A repository hides data-source details within the data layer, while a module hides implementation details from other modules.

For example, the product feature should depend on the product repository contract rather than constructing a Retrofit service or opening a Room database directly. The data module provides the implementation, and the application configures how the implementation is supplied.

Recommended dependency direction

app
 ├── feature:products
 ├── data:products
 └── core modules

feature:products
 └── depends on repository contract

data:products
 ├── implements repository contract
 ├── uses core:network
 └── uses core:database

In a larger project, the repository contract may live in a dedicated API or domain module. In a simpler project, the interface may live in the data module if doing so does not create an unwanted dependency from the feature to the implementation module. The correct location depends on the intended dependency graph.

15. Applying Dependency Inversion

Dependency inversion means high-level application logic should depend on abstractions rather than concrete low-level implementations. It helps keep business logic independent from technical details such as HTTP clients and database libraries.

Without dependency inversion

class ProductViewModel {
    private val repository =
        RetrofitProductRepository.create()
}

This approach directly creates a specific implementation inside the ViewModel. Replacing the implementation or testing the ViewModel becomes harder.

With dependency inversion

class ProductViewModel(
    private val repository: ProductRepository
)

The ViewModel receives the abstraction through its constructor. A dependency injection framework or a manually written factory can supply the appropriate implementation.

16. Dependency Injection for Repository Implementations

Dependency injection (DI) supplies objects with their required dependencies rather than requiring them to construct every dependency themselves. Hilt is one option for Android applications; manual dependency injection is also appropriate for simpler projects.

A simplified Hilt example looks like this:

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class ProductRepositoryModule {

    @Binds
    @Singleton
    abstract fun bindProductRepository(
        implementation: OfflineFirstProductRepository
    ): ProductRepository
}

This binding assumes that OfflineFirstProductRepository can be constructed by Hilt and that its dependencies are also provided. The application must include the appropriate Hilt setup and dependencies. Use a scope such as @Singleton only when a shared instance is appropriate for the repository's state and lifecycle.

17. Defining Module Dependencies with Gradle

Gradle module dependencies are declared in each module's build configuration. For example, a feature module might depend on a domain contract module:

// feature/products/build.gradle.kts
dependencies {
    implementation(project(":domain:products"))
    implementation(project(":core:model"))
}

A data module might depend on the shared network and database modules:

// data/products/build.gradle.kts
dependencies {
    implementation(project(":core:network"))
    implementation(project(":core:database"))
    implementation(project(":domain:products"))
}

The exact dependency declarations depend on the project's module names and where the interfaces and models are defined. The modules must also be included in the root Gradle settings configuration.

Use implementation and api carefully

  • implementation keeps a dependency's implementation details from automatically becoming part of a consumer's compile classpath.
  • api exposes a dependency to consumers when that dependency is intentionally part of the module's public API.
  • Prefer implementation by default, and use api only when consumers truly need access to the exposed types.
  • Keep public APIs small and avoid leaking Retrofit, Room, or other implementation-specific types across module boundaries unnecessarily.

18. Avoiding Circular Dependencies

A circular dependency occurs when module A depends on module B and module B also depends on module A, either directly or through a chain of modules. Such relationships make architecture difficult to understand and often indicate that a responsibility or abstraction is located in the wrong place.

Example of a problematic dependency graph

:feature:products
        |
        v
:data:products
        |
        v
:feature:products

How to resolve it

  1. Identify the interface or model that both modules need.
  2. Move that contract into a suitable shared API, domain, or model module.
  3. Make the feature depend on the contract rather than the data implementation.
  4. Make the data module implement the contract.
  5. Configure the concrete implementation in the application or dependency injection setup.

19. Repository Pattern and Unit Testing

A key advantage of repository abstractions is that ViewModels and use cases can be tested without a live server or production database. Test implementations can return predictable data and simulate errors.

Example fake repository

class FakeProductRepository(
    initialProducts: List<Product> = emptyList()
) : ProductRepository {

    private val products =
        kotlinx.coroutines.flow.MutableStateFlow(initialProducts)

    override fun observeProducts() = products

    override suspend fun refreshProducts() {
        // Keep the predictable test data unchanged.
    }

    override suspend fun getProductById(id: Int): Product? {
        return products.value.firstOrNull { it.id == id }
    }
}

This fake is suitable for testing consumers that depend on the repository interface. For production code, add more realistic fake behavior when tests need to simulate updates, network failures, or local persistence.

What should be tested?

  • Whether the repository returns the expected data.
  • Whether remote data is saved locally after a successful refresh.
  • Whether cached data remains available when a refresh fails.
  • Whether errors are propagated or represented according to the chosen contract.
  • Whether the ViewModel exposes the correct loading, success, empty, and error states.
  • Whether a feature module can be tested without initializing unrelated modules.

20. Common Mistakes and Troubleshooting

Problem Likely Cause Recommended Solution
ViewModel directly calls Retrofit or Room Data access responsibilities are mixed with UI logic. Introduce a repository and move data-source access into the data layer.
Repository contains too many unrelated operations The repository handles multiple unrelated business domains. Split responsibilities into domain-focused repositories.
Too many tiny modules Modularization is being applied without clear boundaries. Combine modules that do not benefit from independent ownership or reuse.
Circular module dependency Contracts and implementations are located in dependent modules. Move the shared contract to a suitable API or domain module.
Gradle cannot resolve a project dependency The module path is wrong or the module is missing from settings. Check the project path and include the module in Gradle settings.
Repository tests require network access The implementation is tightly coupled to a real API client. Use injected dependencies and fake data sources for unit tests.
UI displays stale data The data flow or synchronization policy is unclear. Define a single source of truth and make refresh behavior explicit.
Changes trigger many module rebuilds Public APIs or transitive dependencies are unnecessarily broad. Reduce exposed types and prefer implementation dependencies when appropriate.

21. Best Practices

  • Create repositories to expose data to the rest of the application, even when a simple repository initially has only one data source.
  • Keep UI components focused on rendering and user interactions.
  • Use ViewModels to prepare screen state and coordinate screen-level operations.
  • Use Kotlin coroutines for asynchronous operations and Flow for observable streams when appropriate.
  • Introduce use cases when business logic is complex or shared across multiple ViewModels.
  • Define clear ownership for each module and keep its public API minimal.
  • Keep data-source implementations private or internal when they do not need to be exposed.
  • Prefer high cohesion within a module and low coupling between modules.
  • Use constructor injection to make dependencies explicit and improve testability.
  • Centralize dependency versions where useful and keep Gradle configuration consistent.
  • Do not modularize purely to increase the module count; modularize to enforce meaningful boundaries.
  • Document repository behavior for caching, synchronization, error handling, and offline operation.

22. Practical Exercise

Task: Build a small Android product catalogue using repository patterns and modular architecture.

Requirements

  1. Create a product model containing an ID, name, and price.
  2. Define a ProductRepository interface.
  3. Implement a fake or in-memory repository for initial development.
  4. Create a ViewModel that exposes loading, success, and error states.
  5. Display products in a Jetpack Compose screen or a Fragment-based UI.
  6. Write unit tests for repository behavior and ViewModel state.
  7. As the project grows, extract product data or feature code into Gradle modules.
  8. Document the dependency direction between the app, feature, domain, and data modules.

Expected outcome

The UI should not directly access the database or API. Product data should be obtained through the repository interface, and tests should be able to replace the real repository with a fake implementation. Any added modules should have clearly defined responsibilities and no circular dependencies.

23. Interview Questions and Answers

Q1. What is the repository pattern in Android?

Answer: It provides a consistent interface for accessing application data while hiding the details of APIs, databases, caches, and other data sources.

Q2. Why should a ViewModel not directly access a database?

Answer: Direct access couples UI logic to storage implementation. A repository creates a boundary, making the ViewModel easier to maintain and test.

Q3. What is the difference between a repository and a data source?

Answer: A data source communicates with a particular provider, such as Room or a remote API. A repository coordinates data sources and exposes application-level data operations.

Q4. What is modular architecture?

Answer: It organizes an application into focused modules with explicit dependencies and responsibilities, helping maintainability, reuse, testing, and collaboration.

Q5. What is the difference between a package and a module?

Answer: A package organizes source files by namespace. A Gradle module is a build unit that can define its own configuration and dependencies.

Q6. What is dependency inversion?

Answer: It is a principle in which high-level logic depends on abstractions instead of directly depending on concrete low-level implementations.

Q7. When should an Android project introduce a domain layer?

Answer: A domain layer is useful when business logic is complex, needs to be reused across multiple ViewModels, or would otherwise make screen-level logic difficult to maintain.

Q8. How do repositories improve testability?

Answer: Consumers can depend on an interface and use fake repository implementations during tests, avoiding unnecessary network or database setup.

Q9. What is a circular dependency between modules?

Answer: It occurs when modules depend on each other directly or indirectly. It can often be resolved by moving shared contracts into a separate API or domain module.

Q10. Should every class have its own Gradle module?

Answer: No. Modules should represent meaningful architectural, ownership, reuse, or build boundaries. Too many small modules create unnecessary configuration overhead.

24. Quick Revision Checklist

  • Understand the responsibilities of UI, ViewModel, repository, and data-source components.
  • Know when a domain layer and use cases are useful.
  • Define repository interfaces that expose application-level operations.
  • Keep database and network implementation details behind the repository boundary.
  • Use coroutines and Flow where appropriate for asynchronous and observable data.
  • Distinguish packages from Gradle modules.
  • Keep module dependencies clear, minimal, and acyclic.
  • Use dependency injection or manual composition to supply implementations.
  • Test repositories and ViewModels with fake dependencies.
  • Choose modular boundaries based on real project needs.

25. Conclusion

Applying repository patterns and modular architecture helps Android developers build applications that are easier to understand, test, extend, and maintain. Repositories separate application logic from data-source details, while Gradle modules establish stronger boundaries between features and shared infrastructure.

Start with clear responsibilities and a repository interface. Add local caching, domain use cases, dependency injection, and separate modules as the application's requirements grow. A successful architecture is not the one with the most layers or modules; it is the one that makes dependencies understandable and changes safe.

26. Additional Learning Resources

Previous Next
whatsapp